Aplikasi kasir Android untuk usaha cuci mobil + kantin dalam satu outlet, multi-tenant.
Satu mobil = satu tagihan terbuka yang hidup dari datang sampai diambil. Kantin bisa menempel ke tagihan mobil atau dibayar sendiri.
1. DATANG Kasir: plat + jenis + layanan cuci -> [Masuk antrian]
bon status "open" (belum bayar), Antrian "menunggu", TIKET cetak
2. SAAT ANTRI Buka kartu mobil -> tambah makanan/minuman
[Bayar sekarang] -> item ditandai lunas, tidak ikut tagihan cuci
[Masukkan ke tagihan] -> menambah subtotal kantin mobil itu
Kasir bisa langsung melayani mobil lain; kartu ini tetap hidup
3. CUCI Di kartu: pilih TUKANG -> [Mulai] (status "dikerjakan")
[Selesai] (status "selesai")
Komisi dihitung per tukang dari PaketLayanan.komisi x qty
4. BAYAR [Bayar] di kartu -> rincian cuci + kantin + diskon
metode, dibayar, kembalian -> STRUK nota -> status "bayar"
5. DITINGGAL Mobil selesai tapi diambil sore: tetap di daftar "Belum dibayar"
Cari plat -> [Bayar] saat pemilik datang1. DATANG Kasir: plat + jenis + layanan cuci -> [Masuk antrian]
bon status "open" (belum bayar), Antrian "menunggu", TIKET cetak
2. SAAT ANTRI Buka kartu mobil -> tambah makanan/minuman
[Bayar sekarang] -> item ditandai lunas, tidak ikut tagihan cuci
[Masukkan ke tagihan] -> menambah subtotal kantin mobil itu
Kasir bisa langsung melayani mobil lain; kartu ini tetap hidup
3. CUCI Di kartu: pilih TUKANG -> [Mulai] (status "dikerjakan")
[Selesai] (status "selesai")
Komisi dihitung per tukang dari PaketLayanan.komisi x qty
4. BAYAR [Bayar] di kartu -> rincian cuci + kantin + diskon
metode, dibayar, kembalian -> STRUK nota -> status "bayar"
5. DITINGGAL Mobil selesai tapi diambil sore: tetap di daftar "Belum dibayar"
Cari plat -> [Bayar] saat pemilik datang
### Keputusan yang sudah dikunci | Aspek | Keputusan | |---|---| | Model bisnis | SaaS multi-tenant | | Platform kasir | Android (HP/tablet) | | Offline | **Wajib** โ transaksi jalan tanpa internet, sync saat online | | Struk | Printer thermal Bluetooth (58mm / 80mm) | | Order kantin | Lewat kasir; kantin ikut tagihan mobil atau bayar sendiri | | **Antrian dibuat** | **Saat masuk (sebelum bayar)**, bukan sesudah bayar | | **Pit** | Ditentukan **saat mulai dikerjakan**, bukan saat masuk antrian | | Kantin bayar sendiri | **Item di bon mobil, ditandai lunas** (bukan bon terpisah) | | Tukang | Satu tukang utama per mobil (tahap awal) | | **Laporan** | **Terpisah**: Kas Cuci vs Kas Kantin โ dua buku, dua saldo, satu shift | ### Tiga sumber pesanan | Pelanggan | Jalur | Nomor | |---|---|---| | Cuci saja | Kasir -> Masuk antrian | `A-0001` | | Cuci + makan | Kartu mobil -> + Pesanan | ikut `A-0001` | | Makan saja (lepas) | Kasir -> Pesanan kantin | `K-0001` | **Aturan:** tidak ada kolom penanda baru. Transaksi dianggap **kantin lepas** bila tidak punya item `tipe='cuci'`. Antrian hanya dibuat bila ada item cuci. ### Laporan keuangan dipisah Kasir memegang **dua jenis uang** yang tidak boleh tercampur di tutup kasir:
| Aspek | Keputusan |
|---|---|
| Model bisnis | SaaS multi-tenant |
| Platform kasir | Android (HP/tablet) |
| Offline | Wajib โ transaksi jalan tanpa internet, sync saat online |
| Struk | Printer thermal Bluetooth (58mm / 80mm) |
| Order kantin | Lewat kasir; kantin ikut tagihan mobil atau bayar sendiri |
| Antrian dibuat | Saat masuk (sebelum bayar), bukan sesudah bayar |
| Pit | Ditentukan saat mulai dikerjakan, bukan saat masuk antrian |
| Kantin bayar sendiri | Item di bon mobil, ditandai lunas (bukan bon terpisah) |
| Tukang | Satu tukang utama per mobil (tahap awal) |
| Laporan | Terpisah: Kas Cuci vs Kas Kantin โ dua buku, dua saldo, satu shift |
| Pelanggan | Jalur | Nomor |
|---|---|---|
| Cuci saja | Kasir -> Masuk antrian | A-0001 |
| Cuci + makan | Kartu mobil -> + Pesanan | ikut A-0001 |
| Makan saja (lepas) | Kasir -> Pesanan kantin | K-0001 |
Aturan: tidak ada kolom penanda baru. Transaksi dianggap kantin lepas bila tidak punya item tipe='cuci'. Antrian hanya dibuat bila ada item cuci.
Kasir memegang dua jenis uang yang tidak boleh tercampur di tutup kasir:
KAS CUCI = subtotal item tipe cuci + addon, dikurangi diskon KAS KANTIN = subtotal item tipe kantin
KAS CUCI = subtotal item tipe cuci + addon, dikurangi diskon
KAS KANTIN = subtotal item tipe kantin
- Tab Laporan punya dua jenis tampilan: **Cuci** dan **Kantin**, masing-masing omzet, jumlah transaksi, dan metode bayar sendiri
- Tutup shift menampilkan **dua angka kas sistem** + dua kolom hitung fisik; selisih dihitung per jenis
- Struk tetap satu bila satu mobil memesan keduanya, tetapi baris TOTAL dipisah: `TOTAL CUCI` dan `TOTAL KANTIN`
| **Tata letak** | **Mockup A โ Terang ยท Biru Bersih** (dipilih 7 Nov 2026). Kasir hanya memulai; Antrian jadi pusat kerja; kantin menempel ke mobil atau pesanan lepas `K-`; laporan dua buku |
| Login | SSO Z One |
| Arah desain | **A โ Biru Bersih** (`#2563EB`, aksen `#EA580C`, kartu putih, radius 16px) |
### Status pengerjaan per 7 Nov 2026
| Tahap | Isi | Status |
|---|---|---|
| **1** | Tombol "Masuk antrian" + tiket cetak | โ
rilis app **v0.6.29** (pilihan pit dihapus lagi) |
| **2** | Bayar dari kartu antrian + struk nota | โณ berikutnya |
| **3** | Tambah kantin ke tagihan terbuka + bayar terpisah per item | โณ |
| **4** | Pilih tukang + komisi per tukang + laporan bagi hasil | โณ |
| **5** | Daftar "Belum dibayar" + cari plat untuk mobil ditinggal | โณ |
**Sudah jalan di perangkat:** printer Bluetooth `RPP02N` (sambung otomatis), struk & tiket ESC/POS.
**Belum boleh dipakai produksi:** tombol "Masuk antrian" sudah membuat bon `open`, tetapi **belum ada cara membayarnya dari app** sampai Tahap 2 selesai.
### Dua repo
| Repo | Isi |
|---|---|
| `djvpri/zx-carwash` | Aplikasi Android (Kotlin + Compose) |
| `djvpri/zx-carwash-api` | Backend Fastify + Prisma + Postgres (private) |
Backend live: `http://bg7dxicf3ixlex0nzzussq6u.103.93.129.94.sslip.io` โ `/api/health` โ `{"ok":true,"db":"up"}`
TOTAL CUCI dan TOTAL KANTIN| Tata letak | Mockup A โ Terang ยท Biru Bersih (dipilih 7 Nov 2026). Kasir hanya memulai; Antrian jadi pusat kerja; kantin menempel ke mobil atau pesanan lepas K-; laporan dua buku |
| Login | SSO Z One |
| Arah desain | A โ Biru Bersih (#2563EB, aksen #EA580C, kartu putih, radius 16px) |
| Tahap | Isi | Status |
|---|---|---|
| 1 | Tombol "Masuk antrian" + tiket cetak | โ rilis app v0.6.29 (pilihan pit dihapus lagi) |
| 2 | Bayar dari kartu antrian + struk nota | โณ berikutnya |
| 3 | Tambah kantin ke tagihan terbuka + bayar terpisah per item | โณ |
| 4 | Pilih tukang + komisi per tukang + laporan bagi hasil | โณ |
| 5 | Daftar "Belum dibayar" + cari plat untuk mobil ditinggal | โณ |
Sudah jalan di perangkat: printer Bluetooth RPP02N (sambung otomatis), struk & tiket ESC/POS.
Belum boleh dipakai produksi: tombol "Masuk antrian" sudah membuat bon open, tetapi belum ada cara membayarnya dari app sampai Tahap 2 selesai.
| Repo | Isi |
|---|---|
| djvpri/zx-carwash | Aplikasi Android (Kotlin + Compose) |
| djvpri/zx-carwash-api | Backend Fastify + Prisma + Postgres (private) |
Backend live: http://bg7dxicf3ixlex0nzzussq6u.103.93.129.94.sslip.io โ /api/health โ {"ok":true,"db":"up"}
ZX Carwash = aplikasi kasir Android untuk usaha cuci mobil yang juga punya kantin.
Masalah yang diselesaikan:
Solusi:
Status 29 Okt 2026:
e2e.js).com.zomet.zxcarwash.SQLiteOpenHelper โ bukan Room. Alasan: fase 1 hanya butuh tabel bon + item + outbox, Room menambah KSP/annotation processor tanpa manfaat nyata di tahap ini.sync_outbox di SQLite) โ baris bon ditulis lokal dulu, ditandai belum terkirim.HttpURLConnection bawaan Android, bukan Retrofit/OkHttp. Satu file Api.kt, tanpa dependency jaringan.Struk.kt murni fungsi (bisa diuji unit test), 32 kolom monospace.bt/BluetoothPrinter.kt) โ driver kecil sendiri, pairing + monitor putus + reconnect.update/Updater.kt), auto-cek saat app dibuka + tombol manual.Yang belum di app: login SSO, antrian per bay, shift, member, stok, laporan, dan pengiriman outbox ke server.
acumedis-api supaya cara deploy Coolify & auth seragam di ekosistem Zomet.CROSS_APP_SECRET) โ POST /v1/auth/sso/exchange โ JWT sesi ZX Carwash berisi tenantId + outletId. Semua query di-scope dari token, tidak pernah dari body.clientId (UUID); Transaksi.clientId unik. Kiriman ulang dibalas status: "duplikat" + serverId yang sama, bukan baris kedua.<huruf outlet>-<urut harian> (mis. CC-0001). Bentrok โ server memberi nomor baru bersufiks R.bayar, dibalik saat batal; penanda StokMutasi.ref = "bon:<id>" mencegah potong ganda.tz=420 WIB), bukan batas hari UTC.CROSS_APP_SECRET; requireWritable hanya mengunci endpoint yang menambah data โ baca, ubah status bayar, dan cetak struk tetap jalan saat langganan habis.POST /v1/admin/tenant + /v1/admin/user (header X-Cross-App-Secret).1. Kasir buka app โ token sesi tersimpan โ data master dari SQLite lokal (bukan menunggu jaringan).
2. Transaksi baru โ tulis bon + item lokal, masuk sync_outbox (status pending).
3. Struk langsung dicetak dari data lokal.
4. Online โ app POST /v1/sync/batch (maks 50 bon per batch, urut created_at), server balas mapping clientId โ serverId + nomorBon.
5. Pull master (GET /v1/master?since=) saat app resume; GET /v1/master/versi untuk cek murah sebelum tarik penuh.
Pengganti rencana awal: Room โ SQLite mentah, WorkManager โ pemicu saat app resume + tombol, Retrofit โ HttpURLConnection. Semua karena fase 1 belum butuh mesin itu, dan menambah dependency di app kasir berarti APK lebih besar + lebih banyak yang bisa rusak di HP murah.
Semua tabel operasional punya tenantId (+ outletId) dan kolom clientId untuk idempotensi sync. Schema nyata ada di prisma/schema.prisma โ 17 model.
Tenant(id, nama, plan, langgananSampai, aktif, lisensi)
Outlet(id, tenantId, nama, alamat, telepon, timezone)
User(id, tenantId, email @unique, nama, role[owner|admin|kasir|tukang], pinHash, aktif)
Bay(id, outletId, nama, aktif)
Kendaraan(id, tenantId, plat, jenis, model, memberId) @@unique([tenantId, plat])
Member(id, tenantId, nama, telepon, poin, saldo, level)
PaketLayanan(id, tenantId, nama, durasi, harga Json, komisi, aktif)
Addon(id, tenantId, nama, harga, aktif)
Produk(id, tenantId, nama, kategori, harga, stok, trackStok, aktif)
Transaksi(id, tenantId, outletId, nomorBon, shiftId, kasirId, memberId, bayId,
plat, jenis, status[open|bayar|batal], subtotalCuci, subtotalKantin,
diskon, total, metode[tunai|qris|kartu|transfer], dibayar, kembali,
catatan, clientId @unique, createdAt, syncedAt)
TransaksiItem(id, transaksiId, tipe[cuci|addon|kantin], refId, nama, harga, qty, subtotal)
Antrian(id, transaksiId, bayId, status[menunggu|dikerjakan|selesai|diambil],
mulaiAt, selesaiAt, tukangId, komisi)
Shift(id, tenantId, outletId, kasirId, bukaAt, tutupAt, kasAwal, kasSistem,
kasFisik, selisih, catatan, clientId)
Pembayaran(id, transaksiId, metode, jumlah, ref, createdAt)
StokMutasi(id, produkId, tipe[masuk|keluar|koreksi], qty, ref, createdAt)
SyncOutbox(id, tenantId, deviceId, entitas, clientId, payload Json, status, retry, lastError)
Pengaturan(id, tenantId, outletId, namaUsaha, alamat, noHp, catatanNota, autoUpdate)Tenant(id, nama, plan, langgananSampai, aktif, lisensi)
Outlet(id, tenantId, nama, alamat, telepon, timezone)
User(id, tenantId, email @unique, nama, role[owner|admin|kasir|tukang], pinHash, aktif)
Bay(id, outletId, nama, aktif)
Kendaraan(id, tenantId, plat, jenis, model, memberId) @@unique([tenantId, plat])
Member(id, tenantId, nama, telepon, poin, saldo, level)
PaketLayanan(id, tenantId, nama, durasi, harga Json, komisi, aktif)
Addon(id, tenantId, nama, harga, aktif)
Produk(id, tenantId, nama, kategori, harga, stok, trackStok, aktif)
Transaksi(id, tenantId, outletId, nomorBon, shiftId, kasirId, memberId, bayId,
plat, jenis, status[open|bayar|batal], subtotalCuci, subtotalKantin,
diskon, total, metode[tunai|qris|kartu|transfer], dibayar, kembali,
catatan, clientId @unique, createdAt, syncedAt)
TransaksiItem(id, transaksiId, tipe[cuci|addon|kantin], refId, nama, harga, qty, subtotal)
Antrian(id, transaksiId, bayId, status[menunggu|dikerjakan|selesai|diambil],
mulaiAt, selesaiAt, tukangId, komisi)
Shift(id, tenantId, outletId, kasirId, bukaAt, tutupAt, kasAwal, kasSistem,
kasFisik, selisih, catatan, clientId)
Pembayaran(id, transaksiId, metode, jumlah, ref, createdAt)
StokMutasi(id, produkId, tipe[masuk|keluar|koreksi], qty, ref, createdAt)
SyncOutbox(id, tenantId, deviceId, entitas, clientId, payload Json, status, retry, lastError)
Pengaturan(id, tenantId, outletId, namaUsaha, alamat, noHp, catatanNota, autoUpdate)
### Aturan yang dijaga schema - `@@unique([tenantId, nomorBon])` โ pengaman terakhir nomor bon kembar. - `Transaksi.clientId @unique` โ inti idempotensi sync. - `@@unique([tenantId, outletId])` pada `Pengaturan` โ satu pengaturan per outlet. - `@@unique([entitas, clientId])` pada `SyncOutbox` โ audit sync tidak menumpuk saat retry. ### `refId` di `TransaksiItem` SENGAJA bukan foreign key Satu bon mencampur tiga jenis item, dan tiap jenis menunjuk tabel berbeda: `cuci` โ `PaketLayanan`, `addon` โ `Addon`, `kantin` โ `Produk`. Kolom generik tidak bisa menjadi FK ke satu tabel saja. Percobaan pertama memaksa `refId โ Produk`, hasilnya `Foreign key constraint violated: TransaksiItem_refId_fkey` setiap sync bon yang berisi jasa cuci. Sekarang `refId` hanya `String?` berindeks; integritas dijaga di sisi aplikasi. ### Aturan hitung - `total = subtotalCuci + subtotalKantin - diskon` (tidak boleh negatif) - `subtotalCuci` = item `cuci` + `addon`; `subtotalKantin` = item `kantin` - Komisi per antrian = `PaketLayanan.komisi ร qty item cuci`, bisa ditimpa lewat body `PATCH /v1/antrian/:id` - Stok kantin berkurang saat transaksi `bayar`, dibalik saat `batal`
@@unique([tenantId, nomorBon]) โ pengaman terakhir nomor bon kembar.Transaksi.clientId @unique โ inti idempotensi sync.@@unique([tenantId, outletId]) pada Pengaturan โ satu pengaturan per outlet.@@unique([entitas, clientId]) pada SyncOutbox โ audit sync tidak menumpuk saat retry.Satu bon mencampur tiga jenis item, dan tiap jenis menunjuk tabel berbeda: cuci โ PaketLayanan, addon โ Addon, kantin โ Produk. Kolom generik tidak bisa menjadi FK ke satu tabel saja. Percobaan pertama memaksa refId โ Produk, hasilnya Foreign key constraint violated: TransaksiItem_refId_fkey setiap sync bon yang berisi jasa cuci. Sekarang refId hanya String? berindeks; integritas dijaga di sisi aplikasi.
total = subtotalCuci + subtotalKantin - diskon (tidak boleh negatif)subtotalCuci = item cuci + addon; subtotalKantin = item kantinPaketLayanan.komisi ร qty item cuci, bisa ditimpa lewat body PATCH /v1/antrian/:idbayar, dibalik saat batalBase: http://bg7dxicf3ixlex0nzzussq6u.103.93.129.94.sslip.io
Auth: Authorization: Bearer <token ZX Carwash> โ tenant & outlet diambil dari claim token, bukan dari body.
| Method | Path | Keterangan |
|---|---|---|
| GET | / | identitas service + versi |
| GET | /api/health | {ok, service, version, db} โ db:"up" berarti Postgres terhubung |
| POST | /v1/auth/sso/exchange | {token, deviceId?, outletId?} โ {accessToken, user, tenant, outletId} |
| GET/POST | /api/admin/cross-app | daftar tenant untuk hub Z One (header X-Cross-App-Secret) |
| Method | Path | Keterangan |
|---|---|---|
| GET | /v1/auth/me | profil, role, tenant, outlet |
| GET | /v1/license | {aktif, plan, sampai, sisaHari, maxOutlet, tandaTanganSah} |
| Method | Path | Keterangan |
|---|---|---|
| GET | /v1/master?since=&outletId= | paket, addon, produk, member, kendaraan, bay, users, pengaturan + versi |
| GET | /v1/master/versi | sidik jari isi master โ cek murah sebelum tarik penuh |
| GET | /v1/pengaturan | nama usaha, alamat, no HP, catatan nota |
| PATCH | /v1/pengaturan | ubah field pengaturan (upsert) |
| Method | Path | Keterangan |
|---|---|---|
| POST | /v1/sync/batch | {deviceId, outletId?, items:[{entitas:"bon", clientId, payload}]} โ {berhasil, gagal, hasil:[{clientId, serverId, nomorBon, status, total}]} ยท maks 50 bon/batch |
| GET | /v1/sync/status | jumlah outbox pending/gagal + waktu sync terakhir |
| Method | Path | Keterangan |
|---|---|---|
| GET | /v1/transaksi?tanggal=&status=&limit= | daftar bon + total |
| GET | /v1/transaksi/:id | bon + item + antrian + pembayaran |
| POST | /v1/transaksi/:id/bayar | {metode, dibayar} โ memicu potong stok + antrian selesai |
| POST | /v1/transaksi/:id/batal | {alasan} โ stok dibalik, pembayaran dihapus |
| POST | /v1/transaksi/:id/antrian | {bayId, tukangId} |
| GET | /v1/antrian?status=&bayId= | papan antrian per bay |
| PATCH | /v1/antrian/:id | {status, tukangId?, komisi?} โ isi mulaiAt/selesaiAt + komisi |
| Method | Path | Keterangan |
|---|---|---|
| GET | /v1/shift?limit= | riwayat + shift aktif |
| POST | /v1/shift/buka | {kasAwal} โ 409 kalau masih ada shift terbuka |
| POST | /v1/shift/tutup | {kasFisik, catatan?} โ hitung kasSistem, selisih |
| GET | /v1/shift/:id/rekap | omzet per metode, kas sistem, selisih |
| Method | Path | Keterangan |
|---|---|---|
| GET/POST | /v1/paket ยท /v1/addon ยท /v1/produk ยท /v1/bay ยท /v1/member | daftar & simpan (id ada = ubah) |
| POST | /v1/stok/mutasi | {produkId, tipe, qty} masuk/keluar/koreksi |
| GET | /v1/stok/mutasi?produkId= | riwayat mutasi stok |
| POST | /v1/member/:id/saldo | {poin, saldo} โ nilai negatif boleh |
| Method | Path | Keterangan |
|---|---|---|
| GET | /v1/laporan/harian?tanggal=&tz=420 | omzet, cuci vs kantin, per metode, paket terlaris, jam sibuk, mobil, komisi |
| GET | /v1/laporan/periode?dari=&sampai= | tren harian, item terlaris, komisi per tukang |
| Method | Path | Keterangan |
|---|---|---|
| POST | /v1/admin/tenant | provisioning/perbarui tenant + outlet (idempoten per nama) |
| POST | /v1/admin/user | upsert user tenant |
| GET | /v1/admin/user?tenantId= | daftar user |
401 token tidak ada/rusak ยท 402 langganan habis (hanya endpoint tulis) ยท 403 token app lain atau akun nonaktif ยท 404 bukan milik tenant ini ยท 409 shift masih terbuka / bon batal ยท 413 batch > 50 ยท 200/201 sukses.
Status per 29 Okt 2026: โ ada ยท โณ belum
versi.clientId unik; kirim ulang โ duplikat + serverId sama.CC-0001; bentrok โ sufiks R.PaketLayanan.komisi ร qty.requireWritable yang hanya mengunci endpoint tulis.| Fitur | Catatan |
|---|---|
| Login SSO Z One | server siap; app belum ada layar login |
| Kirim outbox ke /v1/sync/batch | outbox sudah ditulis, tapi tidak pernah dikirim |
| Tarik master dari /v1/master | app masih memakai master statis di Data.kt |
| Antrian per bay + timer | server siap |
| Buka/tutup shift | server siap |
| Member, poin, deposit | server siap |
| Stok kantin + mutasi | server siap |
| Laporan harian & periode | server siap |
menunggu โ dikerjakan โ selesai โ diambil.Arah desain terpilih: A โ Biru Bersih.
Token warna:
--primary: #2563EB --on-primary: #FFFFFF --accent: #EA580C --accent-fg: #B45309 --bg: #F8FAFC --card: #FFFFFF --fg: #1E293B --muted-fg: #475569 --border: #E2E8F0 --ok: #15803D (chip selesai)
--primary: #2563EB --on-primary: #FFFFFF
--accent: #EA580C --accent-fg: #B45309
--bg: #F8FAFC --card: #FFFFFF
--fg: #1E293B --muted-fg: #475569
--border: #E2E8F0 --ok: #15803D (chip selesai)
- Tipografi: Plus Jakarta Sans (700/800 untuk angka total), angka tabular. - Radius: 16px kartu, 14px tombol, 26px frame HP. - Navigasi bawah 4 tab: Kasir ยท Antrian ยท Menu ยท Laporan. - Struk: monospace, dipisah per modul supaya omzet cuci vs kantin terbaca dari kertas. - Prinsip: angka total paling besar di layar; status sync selalu kelihatan; satu tangan bisa operasikan. Layar kunci: Kasir (bill gabungan), Antrian & Bay, Pembayaran + Struk. Lihat bagian **Mockup**.
Layar kunci: Kasir (bill gabungan), Antrian & Bay, Pembayaran + Struk. Lihat bagian Mockup.

Mockup A - Biru Bersih (terpilih)

Mockup B - Teal Cepat

Mockup C - Bay Gelap

Mockup A - Pengaturan, lisensi, update
refId dipaksa jadi FK ke Produk โ sync gagal total.** Item bon mencampur jasa cuci (โ PaketLayanan), add-on (โ Addon), dan kantin (โ Produk). Satu kolom generik tidak bisa FK ke satu tabel. Gejalanya Foreign key constraint violated: TransaksiItem_refId_fkey, dan karena dibungkus transaksi DB, seluruh bon gagal โ bukan cuma itemnya. Solusi: refId String? berindeks, tanpa relasi.new Date().toISOString().slice(0,10) menghasilkan tanggal 28. Laporan harian tampak bon=0 padahal bon baru saja dibuat. Rumus yang benar: hariLokal = new Date(now + tzMenit*60000).toISOString().slice(0,10), lalu gte = hariLokal 00:00 UTC โ tzMenit.selesai saat bon bayar, jadi selesaiAt sudah terisi dan blok perhitungan komisi (if (!selesaiAt)) terlewati. Perhitungan komisi harus dipisah dari pengisian selesaiAt.tenantId, sehingga bon yang dibatalkan tetap terhitung sebagai mobil tercuci. Wajib transaksi: { tenantId, status: 'bayar' }.DATABASE_URL Coolify berisi password salah.** Password Postgres 64 karakter, tapi URL yang terpasang memakai potongan 23 karakter. health balas db:"down" dan Prisma: Authentication failed ... credentials for postgres are not valid. Fix: ambil postgres_password dari API database Coolify, lalu PATCH /api/v1/applications/<uuid>/envs dengan body {key, value, is_preview} โ bukan PATCH .../envs/<envUuid> (itu balas 404).PATCH /envs menambah baris, tidak menimpa.** Setelah perbaikan URL ada 2 baris DATABASE_URL; container memakai yang mana pun yang terakhir. Hapus duplikat lewat DELETE /envs/<uuid>, sisakan satu.@@unique([tenantId, nomorBon]) menangkapnya; sync menangkap P2002 lalu memberi nomor bersufiks R alih-alih gagal total.clientId unik, retry = bon dobel.tenantId dari token; jangan percaya tenantId dari body request.npm test โ 13 test murni tanpa DB (auth, hitung bon, rentang WIB, lisensi HMAC, header admin).node e2e.js โ 27 cek terhadap server live: SSO sah/palsu/app-salah, master, shift, sync, idempotensi, stok, komisi, laporan, rekap, batal, lisensi, cross-app. Semua lulus 29 Okt 2026.Field yang bisa diubah user:
| Field | Contoh | Dipakai di |
|---|---|---|
| Nama usaha | CUCI KANTIN ZOMET | header struk, layar login/beranda |
| Alamat usaha | Jl. Melati 12, Pontianak | header struk (2 baris, wrap otomatis) |
| Nomor HP | 0812-3456-789 | header struk + tombol "Hubungi" |
| Catatan nota | Terima kasih, barang tertinggal bukan tanggung jawab kami | footer struk, 2โ3 baris |
Aturan:
pengaturan (scope outlet_id), cache lokal di Room โ struk tetap benar saat offline....).pengaturan(id, tenant_id, outlet_id, nama_usaha, alamat, no_hp, catatan_nota,
logo_url?, auto_update BOOLEAN, updated_at)pengaturan(id, tenant_id, outlet_id, nama_usaha, alamat, no_hp, catatan_nota,
logo_url?, auto_update BOOLEAN, updated_at)
### Update aplikasi
Dua jalur distribusi, dua mekanisme:
**A. APK sideload (unduh langsung / share ke kasir)**
- `GET /app/versi?platform=android&version_code=<n>` โ `{version_code, version_name, min_supported, url_apk, changelog[], wajib:boolean}`.
- Auto-check: saat app dibuka + tiap 24 jam (WorkManager, hanya saat online). Ada versi baru โ dialog changelog + tombol **Update sekarang**.
- Tombol manual **"Cek update terbaru"** di Pengaturan โ panggil endpoint yang sama, tampilkan hasil eksplisit: "Sudah versi terbaru (0.1.4)" atau "Versi 0.1.5 tersedia".
- Unduh APK โ verifikasi `sha256` dari response โ buka installer via `FileProvider` + `REQUEST_INSTALL_PACKAGES`.
- `wajib: true` (versi di bawah `min_supported`) โ dialog tidak bisa ditutup; `min_supported` mencegah kasir pakai versi yang format sync-nya sudah tidak didukung server.
**B. Play Store (kalau dirilis ke Play)**
- **Play Core In-App Updates**: `appUpdateManager.startUpdateFlow()` mode `FLEXIBLE` (default) atau `IMMEDIATE` bila `wajib`.
- Tombol "Cek update terbaru" โ `appUpdateInfo.updateAvailability()`; kalau `UPDATE_NOT_AVAILABLE` tampilkan pesan itu apa adanya, jangan fake progress.
- Auto update di Play Store diurus Play; toggle `auto_update` di app hanya relevan untuk jalur APK.
Pitfall:
- Jangan auto-install diam-diam saat ada transaksi berjalan / shift belum ditutup โ jadwalkan setelah tutup shift atau saat app idle.
- Auto-check jangan blokir start app: jalan di background, UI tetap bisa dipakai offline.
- Update APK menghapus sesi kalau signature berubah โ pakai keystore yang sama terus (simpan di CI secret).
- Cek versi butuh jaringan; saat offline tombol harus bilang "butuh koneksi", bukan spinner menggantung.
### Footer struk (format tetap)
Urutan dari atas ke bawah โ bagian atas/identitas mengikuti Pengaturan, bagian bawah **fixed** dari app:
Dua jalur distribusi, dua mekanisme:
A. APK sideload (unduh langsung / share ke kasir)
GET /app/versi?platform=android&version_code=<n> โ {version_code, version_name, min_supported, url_apk, changelog[], wajib:boolean}.sha256 dari response โ buka installer via FileProvider + REQUEST_INSTALL_PACKAGES.wajib: true (versi di bawah min_supported) โ dialog tidak bisa ditutup; min_supported mencegah kasir pakai versi yang format sync-nya sudah tidak didukung server.B. Play Store (kalau dirilis ke Play)
appUpdateManager.startUpdateFlow() mode FLEXIBLE (default) atau IMMEDIATE bila wajib.appUpdateInfo.updateAvailability(); kalau UPDATE_NOT_AVAILABLE tampilkan pesan itu apa adanya, jangan fake progress.auto_update di app hanya relevan untuk jalur APK.Pitfall:
Urutan dari atas ke bawah โ bagian atas/identitas mengikuti Pengaturan, bagian bawah fixed dari app:
<NAMA USAHA> <- pengaturan.nama_usaha
<alamat usaha> <- pengaturan.alamat (wrap 2 baris)
<no HP> <- pengaturan.no_hp
--------------------------------
<item transaksi>
--------------------------------
TOTAL <total>
<metode bayar> <jumlah>
--------------------------------
<catatan_nota> <- pengaturan.catatan_nota, maks 3 baris
--------------------------------
TERIMA KASIH <- FIXED, tidak bisa diubah user
Powered by ZX Carwash App <- FIXED, tidak bisa diubah user
<nomor bon> ยท <jam><NAMA USAHA> <- pengaturan.nama_usaha
<alamat usaha> <- pengaturan.alamat (wrap 2 baris)
<no HP> <- pengaturan.no_hp
--------------------------------
<item transaksi>
--------------------------------
TOTAL <total>
<metode bayar> <jumlah>
--------------------------------
<catatan_nota> <- pengaturan.catatan_nota, maks 3 baris
--------------------------------
TERIMA KASIH <- FIXED, tidak bisa diubah user
Powered by ZX Carwash App <- FIXED, tidak bisa diubah user
<nomor bon> ยท <jam>
Aturan: - `TERIMA KASIH` dicetak **letter-spaced** (1 spasi antar huruf) + bold, ukuran 1 tingkat di atas body. - `Powered by ZX Carwash App` ukuran kecil (font B / 9โ10 dot), rata tengah, selalu baris terakhir sebelum nomor bon. - Dua baris itu **konstanta di template struk**, bukan field pengaturan โ user tidak bisa menghapus/mengubah (branding). - Kalau `catatan_nota` kosong, blok catatan dilewati; `TERIMA KASIH` + `Powered by` tetap tercetak. - Struk 58mm: `Powered by ZX Carwash App` = 25 karakter, aman 1 baris pada font B; jangan di-wrap.
Aturan:
TERIMA KASIH dicetak letter-spaced (1 spasi antar huruf) + bold, ukuran 1 tingkat di atas body.Powered by ZX Carwash App ukuran kecil (font B / 9โ10 dot), rata tengah, selalu baris terakhir sebelum nomor bon.catatan_nota kosong, blok catatan dilewati; TERIMA KASIH + Powered by tetap tercetak.Powered by ZX Carwash App = 25 karakter, aman 1 baris pada font B; jangan di-wrap.Inti perubahan: antrian dibuat saat mobil datang, bukan sesudah bayar. Bon hidup beberapa jam.
+-------------------+
Kasir: Masuk | Transaksi "open" | (uang belum diterima)
antrian -------> | Antrian menunggu |
+---------+---------+
|
+---------------+----------------+
| |
tambah kantin [Mulai] -> pilih tukang + pit kosong
(bayar sendiri / Antrian "dikerjakan"
masuk tagihan) (pit tercatat di sini)
| |
| [Selesai]
| |
| v
| Antrian "selesai"
| |
+-----------> [Bayar] <----------+
|
v
Transaksi "bayar" + Pembayaran
Antrian "diambil"+-------------------+
Kasir: Masuk | Transaksi "open" | (uang belum diterima)
antrian -------> | Antrian menunggu |
+---------+---------+
|
+---------------+----------------+
| |
tambah kantin [Mulai] -> pilih tukang + pit kosong
(bayar sendiri / Antrian "dikerjakan"
masuk tagihan) (pit tercatat di sini)
| |
| [Selesai]
| |
| v
| Antrian "selesai"
| |
+-----------> [Bayar] <----------+
|
v
Transaksi "bayar" + Pembayaran
Antrian "diambil"
### Aturan keras 1. **Antrian hanya untuk layanan cuci.** Bon kantin murni langsung `Bayar`, tanpa antrian dan tanpa pit. 2. **Pit TIDAK dipilih saat mobil masuk.** Kasir hanya mencatat plat + layanan. Pit ditentukan saat tukang mulai mengerjakan, dengan melihat pit mana yang kosong. Alasannya: mobil bisa menunggu di halaman; pit baru relevan ketika dicuci. 3. **Bon `open` tidak boleh dihapus** โ hanya bisa dibayar atau dibatalkan. Kalau pelanggan pergi, batalkan dengan alasan. 4. **Kantin yang dibayar sendiri tetap item bon itu**, ditandai lunas; tidak ikut total tagihan cuci. 5. **Komisi tukang dihitung server** saat antrian selesai, bukan dari app. 6. **Kasir bisa melayani banyak mobil sekaligus**: kartu antrian dibuka sebagai panel detail, bukan halaman penuh.
1. Antrian hanya untuk layanan cuci. Bon kantin murni langsung Bayar, tanpa antrian dan tanpa pit.
2. Pit TIDAK dipilih saat mobil masuk. Kasir hanya mencatat plat + layanan.
Pit ditentukan saat tukang mulai mengerjakan, dengan melihat pit mana yang kosong.
Alasannya: mobil bisa menunggu di halaman; pit baru relevan ketika dicuci.
3. **Bon open tidak boleh dihapus** โ hanya bisa dibayar atau dibatalkan. Kalau pelanggan pergi, batalkan dengan alasan.
4. Kantin yang dibayar sendiri tetap item bon itu, ditandai lunas; tidak ikut total tagihan cuci.
5. Komisi tukang dihitung server saat antrian selesai, bukan dari app.
6. Kasir bisa melayani banyak mobil sekaligus: kartu antrian dibuka sebagai panel detail, bukan halaman penuh.
Tiga kebutuhan yang belum ada di schema sekarang.
model TransaksiItem {
// ...kolom lama...
bayarSendiri Boolean @default(false) // kantin yang dibayar saat dipesan
bayarAt DateTime? // kapan item itu lunas
}model TransaksiItem {
// ...kolom lama...
bayarSendiri Boolean @default(false) // kantin yang dibayar saat dipesan
bayarAt DateTime? // kapan item itu lunas
}
Total tagihan cuci = `sum(subtotal) where tipe='cuci' or bayarSendiri=false`. ### 2. Tukang per pekerjaan `Antrian.tukangId` sudah ada (satu tukang). Kalau nanti dua orang satu mobil:
Total tagihan cuci = sum(subtotal) where tipe='cuci' or bayarSendiri=false.
Antrian.tukangId sudah ada (satu tukang). Kalau nanti dua orang satu mobil:
model AntrianTukang {
id String @id @default(cuid())
antrianId String
userId String
porsi Int @default(100) // persen komisi
komisi Int @default(0)
@@unique([antrianId, userId])
}model AntrianTukang {
id String @id @default(cuid())
antrianId String
userId String
porsi Int @default(100) // persen komisi
komisi Int @default(0)
@@unique([antrianId, userId])
}
### 3. Riwayat status antrian
model AntrianLog {
id String @id @default(cuid())
antrianId String
dari String
ke String
olehId String?
createdAt DateTime @default(now())
}model AntrianLog {
id String @id @default(cuid())
antrianId String
dari String
ke String
olehId String?
createdAt DateTime @default(now())
}
Dipakai untuk menjawab "mobil ini sudah berapa lama menunggu" dan "siapa yang menyelesaikan". ### Endpoint baru yang dibutuhkan | Method | Path | Untuk | |---|---|---| | POST | `/v1/transaksi/:id/item` | tambah kantin ke bon terbuka (hitung ulang total di server) | | POST | `/v1/transaksi/:id/item/:itemId/bayar` | bayar item kantin saja | | DELETE | `/v1/transaksi/:id/item/:itemId` | hapus item (hanya bila belum dibayar) | | POST | `/v1/transaksi/:id/bayar` | **sudah ada** โ dipakai Tahap 2 | | PATCH | `/v1/antrian/:id` | tukang, mulai, selesai | | GET | `/v1/antrian?status=open` | daftar "belum dibayar" untuk mobil ditinggal |
Dipakai untuk menjawab "mobil ini sudah berapa lama menunggu" dan "siapa yang menyelesaikan".
| Method | Path | Untuk |
|---|---|---|
| POST | /v1/transaksi/:id/item | tambah kantin ke bon terbuka (hitung ulang total di server) |
| POST | /v1/transaksi/:id/item/:itemId/bayar | bayar item kantin saja |
| DELETE | /v1/transaksi/:id/item/:itemId | hapus item (hanya bila belum dibayar) |
| POST | /v1/transaksi/:id/bayar | sudah ada โ dipakai Tahap 2 |
| PATCH | /v1/antrian/:id | tukang, mulai, selesai |
| GET | /v1/antrian?status=open | daftar "belum dibayar" untuk mobil ditinggal |
Tiap tahap bisa dipakai sendiri; tidak ada tahap yang menunggu tahap lain untuk dirilis, kecuali disebut.
Tujuan: alur inti tertutup (mobil bisa dicuci, selesai, lalu dibayar).
POST /v1/transaksi/:id/bayar dipakai dari app (endpoint sudah ada di server)bayar, Antrian diambil, struk nota tercetakSelesai bila: mobil masuk antrian โ selesai โ bayar โ struk keluar, tanpa membuat bon baru.
Tujuan: pelanggan bisa memesan selama antri; kasir tetap melayani mobil lain.
POST /v1/transaksi/:id/item)Selesai bila: satu mobil punya 2 pesanan kantin, satu dibayar sendiri satu masuk tagihan, struk akhir menunjukkan keduanya dengan benar.
Tujuan: komisi per tukang bisa dipakai menghitung bagi hasil.
PaketLayanan.komisi ร qty, disimpan saat selesaiSelesai bila: satu periode menampilkan komisi per nama tukang dan angkanya cocok dengan hitungan manual.
Tujuan: mobil yang dicuci pagi dan diambil sore tidak hilang dari pandangan.
Selesai bila: mobil dari pagi bisa ditemukan lewat plat dan dibayar sore hari.
Keputusan (7 Nov 2026): uang cuci dan uang kantin tidak dicampur, termasuk di tutup kasir.
Satu shift bisa menutup dengan dua saldo berbeda.
Kas Cuci = sum(item.subtotal where tipe in (cuci, addon)) - diskon Kas Kantin = sum(item.subtotal where tipe = kantin) Total bon = Kas Cuci + Kas Kantin (yang dibayar pelanggan sekali)
Kas Cuci = sum(item.subtotal where tipe in (cuci, addon)) - diskon
Kas Kantin = sum(item.subtotal where tipe = kantin)
Total bon = Kas Cuci + Kas Kantin (yang dibayar pelanggan sekali)
`diskon` saat ini berlaku untuk satu bon. **Aturan baru:** diskon hanya mengurangi **Kas Cuci**. Alasan: diskon promo cuci tidak boleh mengurangi setoran kantin ke pemilik warung/makanan. ### Arti angka di tutup kasir
diskon saat ini berlaku untuk satu bon. Aturan baru: diskon hanya mengurangi Kas Cuci.
Alasan: diskon promo cuci tidak boleh mengurangi setoran kantin ke pemilik warung/makanan.
KAS CUCI sistem (dari transaksi) Rp 450.000 fisik (dihitung kasir) Rp 450.000 selisih Rp 0 KAS KANTIN sistem Rp 186.000 fisik Rp 186.000 selisih Rp 0 TOTAL DITERIMA Rp 636.000
KAS CUCI
sistem (dari transaksi) Rp 450.000
fisik (dihitung kasir) Rp 450.000
selisih Rp 0
KAS KANTIN
sistem Rp 186.000
fisik Rp 186.000
selisih Rp 0
TOTAL DITERIMA Rp 636.000
Kalau selisih hanya muncul di salah satu, penyebabnya bisa langsung ditelusuri (uang kembalian cuci vs uang warung). ### Laporan harian & periode | Tab | Isi | |---|---| | **Cuci** | omzet cuci, jumlah mobil, paket terlaris, komisi tukang, rata-rata durasi | | **Kantin** | omzet kantin, jumlah pesanan, produk terlaris, kantin lepas vs kantin ikut mobil | | **Gabungan** | total omzet + perbandingan dua sumber (grafik) | **Kantin dipisah lagi dua baris** karena sumber uangnya beda:
Kalau selisih hanya muncul di salah satu, penyebabnya bisa langsung ditelusuri (uang kembalian cuci vs uang warung).
| Tab | Isi |
|---|---|
| Cuci | omzet cuci, jumlah mobil, paket terlaris, komisi tukang, rata-rata durasi |
| Kantin | omzet kantin, jumlah pesanan, produk terlaris, kantin lepas vs kantin ikut mobil |
| Gabungan | total omzet + perbandingan dua sumber (grafik) |
Kantin dipisah lagi dua baris karena sumber uangnya beda:
Kantin ikut mobil (dibayar bersama cuci) Kantin lepas (dibayar sendiri, tanpa cuci)
Kantin ikut mobil (dibayar bersama cuci)
Kantin lepas (dibayar sendiri, tanpa cuci)
### Struk Satu mobil yang memesan makanan dan cuci tetap **satu struk**, tetapi totalnya dipisah:
Satu mobil yang memesan makanan dan cuci tetap satu struk, tetapi totalnya dipisah:
------------------------------ Reguler 15.000 Es Teh Manis 4.000 ------------------------------ TOTAL CUCI 15.000 TOTAL KANTIN 4.000 ------------------------------ TOTAL 19.000 Tunai 20.000 Kembali 1.000
------------------------------
Reguler 15.000
Es Teh Manis 4.000
------------------------------
TOTAL CUCI 15.000
TOTAL KANTIN 4.000
------------------------------
TOTAL 19.000
Tunai 20.000
Kembali 1.000
Bila hanya kantin (lepas), struk memakai judul **STRUK KANTIN** dan nomor seri `K-0001`.
### Yang perlu diubah di server (belum dikerjakan)
| Bagian | Perubahan |
|---|---|
| `laporan.harian` | sudah menghitung `subtotalCuci` & `subtotalKantin` โ tambahkan blok `kas: { cuci, kantin }` |
| `laporan.periode` | idem, tambah `kantinLepas` vs `kantinMobil` |
| `shift.tutup` | simpan dua angka fisik + dua selisih, bukan satu |
| `Transaksi` | tidak perlu kolom baru โ kantin lepas dikenali dari tidak adanya item cuci |
| Nomor bon | seri terpisah: `A-` untuk bon berantrian, `K-` untuk kantin lepas |
### Yang perlu diubah di app (belum dikerjakan)
- Tab **Laporan**: pemilih `Cuci | Kantin | Gabungan`
- Tutup shift: dua kolom hitung fisik
- Kasir: tombol **Pesanan kantin** (nomor `K-`), tanpa plat dan tanpa antrian
- Struk: baris `TOTAL CUCI` / `TOTAL KANTIN` bila bon berisi keduanya
Bila hanya kantin (lepas), struk memakai judul STRUK KANTIN dan nomor seri K-0001.
| Bagian | Perubahan |
|---|---|
| laporan.harian | sudah menghitung subtotalCuci & subtotalKantin โ tambahkan blok kas: { cuci, kantin } |
| laporan.periode | idem, tambah kantinLepas vs kantinMobil |
| shift.tutup | simpan dua angka fisik + dua selisih, bukan satu |
| Transaksi | tidak perlu kolom baru โ kantin lepas dikenali dari tidak adanya item cuci |
| Nomor bon | seri terpisah: A- untuk bon berantrian, K- untuk kantin lepas |
Cuci | Kantin | GabunganK-), tanpa plat dan tanpa antrianTOTAL CUCI / TOTAL KANTIN bila bon berisi keduanyaDua arah warna, isi layar sama supaya yang dibandingkan tampilannya, bukan teksnya.
Lima layar per arah: Kasir, Antrian (tablet), Antrian (HP), Pesanan kantin, Laporan.
| Sekarang | Baru |
|---|---|
| Kasir: plat + jenis + pit + layanan + add-on + kantin + bon kanan | Kasir hanya memulai: plat, jenis, layanan, satu tombol |
| Antrian: kartu, ubah status, cetak tiket | Antrian jadi pusat kerja: daftar + panel detail, tambah pesanan, mulai, bayar |
| Kantin menempel di Kasir | Kantin menempel ke mobil (dari kartu) atau jadi pesanan lepas bernomor K- |
| Laporan satu angka | Dua buku: Kas Cuci & Kas Kantin, masing-masing sistem/fisik/selisih |
| HP: halaman penuh per layar | HP: daftar penuh + sheet detail dari bawah |
#2563EB biru, #EA580C oranye, latar #F8FAFC, kartu putih, radius 14px.
Tidak mengubah identitas visual app, hanya alurnya.
Latar #0B1220, kartu #131C2E, primer #38BDF8, badge lebih menyala.
Alasan: HP kasir dipakai di area basah/terbuka; latar gelap lebih tahan pantulan sinar,
tetapi berbeda jauh dari tampilan sekarang dan perlu penyesuaian semua layar.
A โ Terang ยท Biru Bersih dipakai. Arah B (gelap) tidak dilanjutkan.
Token warna tetap seperti app sekarang: #2563EB primer, #EA580C aksen, latar #F8FAFC, kartu #FFFFFF, radius 14px, teks #1E293B, redup #475569.
tidak ada kartu terpotong tepi frame, kontras teks redup lolos di kedua arah warna.
karena sebelumnya terlalu redup untuk informasi yang menentukan.

Mockup A terang

Mockup B gelap